feat: add server-synced user preferences infrastructure (#484)#1189
feat: add server-synced user preferences infrastructure (#484)#1189gusa4grr wants to merge 5 commits intonpmx-dev:mainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
|
haven't reviewed the code yet, but on the proposed changes:
The usual pattern is this:
we should then end up in a state where our prefs save to local storage and take effect immediately, but meanwhile get synced to the server in the background. i think we probably also need a toggle somewhere to disable sync, in case you want different prefs per machine. same way chrome works today |
Lunaria Status Overview🌕 This pull request will trigger status changes. Learn moreBy default, every PR changing files present in the Lunaria configuration's You can change this by adding one of the keywords present in the Tracked Files
Warnings reference
|
|
@43081j 👋🏻 Updated the MR description and pushed one more commit. if you have time - please look throught the code 🙂 Much appreciated! I will try to find time during the work week to finalise this, as not much is left 🤞🏻 |
96ad934 to
55c4157
Compare
| } | ||
| } | ||
|
|
||
| export function useRelativeDates() { |
There was a problem hiding this comment.
we should probably split these up into app/composables/preferences/whatever.ts
e.g. app/composables/preferences/useRelativeDates.ts
to follow convention of the rest of the repo
55c4157 to
262f23d
Compare
add3eba to
3c8b850
Compare
- introduce the foundational layer for persisting user preferences to the server - add UserPreferencesSchema and shared types for user preferences - add client-only sync composable with debounced saves, route guard flush, and sendBeacon fallback - integrate server sync into useSettings and migrate to shared UserPreferences type - extract generic localStorage helpers, migrate consumers, remove usePreferencesProvider
- extract sidebar collapsed state into separate `usePackageSidebarPreferences` composable - add `preferences-sync.client.ts` plugin for early color mode + server sync init - wrap theme select in `<ClientOnly>` to prevent SSR hydration mismatch - show sync status indicator on settings page for authenticated users - add `useColorModePreference` composable to sync color mode with `@nuxtjs/color-mode`
3c8b850 to
955381d
Compare
955381d to
e56d0d9
Compare
📝 WalkthroughWalkthroughReplaces the legacy useSettings with a split preferences model: a client-only useUserLocalSettings for ephemeral UI state and a server-backed user preferences system (provider, sync client, server APIs, KV-backed store, and shared schema). Adds localStorage/storage abstractions and many userPreferences composables (accent color, background theme, color mode, search provider, relative dates), updates components and tests to consume the new APIs, removes useSettings and usePreferencesProvider, and adds a client plugin to initialise preference sync and apply stored colour mode. Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (6)
app/composables/userPreferences/useUserPreferencesState.ts (1)
1-9: Align the “read-only” wording with the mutable return value.The docstring promises read-only access, but the returned ref is writable. Either clarify the wording or enforce immutability so expectations are consistent.
shared/schemas/userPreferences.ts (1)
35-38: Type definitions could drift from schema.The types
AccentColorIdandBackgroundThemeIdare derived from the constants, whilstColorModePreferenceandSearchProviderare manually defined literals. Consider deriving these from the schema for consistency:♻️ Suggested approach
+import type { InferOutput } from 'valibot' + +// Derive types from schemas to prevent drift +export type ColorModePreference = InferOutput<typeof ColorModePreferenceSchema> +export type SearchProvider = InferOutput<typeof SearchProviderSchema> export type AccentColorId = keyof typeof ACCENT_COLORS.light export type BackgroundThemeId = keyof typeof BACKGROUND_THEMES -export type ColorModePreference = 'light' | 'dark' | 'system' -export type SearchProvider = 'npm' | 'algolia'app/composables/useUserPreferencesSync.client.ts (2)
137-144: Route guard uses fire-and-forget pattern which may lose data.The
void flushPendingSync()call allows navigation to proceed without awaiting the save. If the network request fails or is slow, the user may navigate away before their preferences are persisted.Consider awaiting the flush to ensure data is saved before navigation:
♻️ Suggested fix
function setupRouteGuard(getPreferences: () => UserPreferences): void { router.beforeEach(async (_to, _from, next) => { if (hasPendingChanges && isAuthenticated.value) { - void flushPendingSync(getPreferences()) + await flushPendingSync(getPreferences()) } next() }) }
32-41: Silent error handling may mask connectivity issues.
fetchServerPreferencescatches all errors and returnsnull, making it indistinguishable from "user has no saved preferences" vs "network/server error". Consider logging errors or updating the sync state to reflect fetch failures.server/utils/preferences/user-preferences-store.ts (1)
23-35: Minor: Double timestamp assignment inmerge.Line 30 sets
updatedAt, then line 33 callsthis.set()which setsupdatedAtagain on line 18. This is functionally correct but slightly redundant.♻️ Optional simplification
async merge(did: string, partial: Partial<UserPreferences>): Promise<UserPreferences> { const existing = await this.get(did) const base = existing ?? { ...DEFAULT_USER_PREFERENCES } const merged: UserPreferences = { ...base, ...partial, - updatedAt: new Date().toISOString(), } - await this.set(did, merged) + await this.storage.setItem(did, { + ...merged, + updatedAt: new Date().toISOString(), + }) return merged }app/composables/useUserPreferencesProvider.ts (1)
77-95: Avoid echoing server‑loaded prefs back to the server.
In the auth watcher, assigningpreferences.valuetriggers the deep watch and schedules a sync, even though the data just came from the server. This creates redundant PUTs and can churn timestamps. Consider suppressingscheduleSyncwhile applying server prefs.♻️ Suggested refactor
const isSyncing = computed(() => status.value === 'syncing') const isSynced = computed(() => status.value === 'synced') const hasError = computed(() => status.value === 'error') + let isApplyingServerPrefs = false async function initSync(): Promise<void> { @@ watch( preferences, newPrefs => { - if (isAuthenticated.value) { + if (isAuthenticated.value && !isApplyingServerPrefs) { scheduleSync(newPrefs) } }, { deep: true }, ) watch(isAuthenticated, async newIsAuth => { if (newIsAuth) { const serverPrefs = await loadFromServer() if (serverPrefs) { const merged = { ...defaultValue, ...preferences.value, ...serverPrefs } if (!arePreferencesEqual(preferences.value, merged)) { - preferences.value = merged + isApplyingServerPrefs = true + preferences.value = merged + isApplyingServerPrefs = false } } } })
| export function useLocalStorageHashProvider<T extends object>(key: string, defaultValue: T) { | ||
| const provider = createLocalStorageProvider<T>(key) | ||
| const data = ref<T>(defaultValue) | ||
|
|
||
| onMounted(() => { | ||
| const stored = provider.get() | ||
| if (stored) { | ||
| data.value = defu(stored, defaultValue) | ||
| } | ||
| }) | ||
|
|
||
| function save() { | ||
| provider.set(data.value) | ||
| } | ||
|
|
||
| function reset() { | ||
| data.value = { ...defaultValue } | ||
| provider.remove() | ||
| } | ||
|
|
||
| function update<K extends keyof T>(key: K, value: T[K]) { | ||
| data.value[key] = value | ||
| save() |
There was a problem hiding this comment.
Prevent shared default mutations in the storage provider
data is initialised with defaultValue by reference and reset() only shallow‑clones, so nested arrays/objects can mutate the defaults and make resets or new instances pick up modified values. Deep‑clone defaults on init/reset and when merging stored values.
🛠️ Suggested fix
export function useLocalStorageHashProvider<T extends object>(key: string, defaultValue: T) {
const provider = createLocalStorageProvider<T>(key)
- const data = ref<T>(defaultValue)
+ const data = ref<T>(structuredClone(defaultValue))
onMounted(() => {
const stored = provider.get()
if (stored) {
- data.value = defu(stored, defaultValue)
+ data.value = defu(stored, structuredClone(defaultValue))
}
})
function reset() {
- data.value = { ...defaultValue }
+ data.value = structuredClone(defaultValue)
provider.remove()
}| async function initSync(): Promise<void> { | ||
| if (syncInitialized || import.meta.server) return | ||
| syncInitialized = true | ||
|
|
||
| setupRouteGuard(() => preferences.value) | ||
| setupBeforeUnload(() => preferences.value) | ||
|
|
||
| if (isAuthenticated.value) { | ||
| const serverPrefs = await loadFromServer() | ||
| if (serverPrefs) { | ||
| const merged = { ...preferences.value, ...serverPrefs } | ||
| if (!arePreferencesEqual(preferences.value, merged)) { | ||
| preferences.value = merged | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Prevent initSync from getting stuck on load failures.
If loadFromServer() throws (offline/5xx), initSync exits before watchers/guards are registered, yet syncInitialized stays true, so subsequent calls no‑op. Wrap the fetch in a try/catch and ensure setup continues even on failure (or reset the flag).
🛠️ Suggested fix
async function initSync(): Promise<void> {
if (syncInitialized || import.meta.server) return
syncInitialized = true
setupRouteGuard(() => preferences.value)
setupBeforeUnload(() => preferences.value)
- if (isAuthenticated.value) {
- const serverPrefs = await loadFromServer()
- if (serverPrefs) {
- const merged = { ...preferences.value, ...serverPrefs }
- if (!arePreferencesEqual(preferences.value, merged)) {
- preferences.value = merged
- }
- }
- }
+ if (isAuthenticated.value) {
+ try {
+ const serverPrefs = await loadFromServer()
+ if (serverPrefs) {
+ const merged = { ...preferences.value, ...serverPrefs }
+ if (!arePreferencesEqual(preferences.value, merged)) {
+ preferences.value = merged
+ }
+ }
+ } catch (_err) {
+ // allow init to complete; sync status is handled by useUserPreferencesSync
+ }
+ }As per coding guidelines: “Use error handling patterns consistently.”
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async function initSync(): Promise<void> { | |
| if (syncInitialized || import.meta.server) return | |
| syncInitialized = true | |
| setupRouteGuard(() => preferences.value) | |
| setupBeforeUnload(() => preferences.value) | |
| if (isAuthenticated.value) { | |
| const serverPrefs = await loadFromServer() | |
| if (serverPrefs) { | |
| const merged = { ...preferences.value, ...serverPrefs } | |
| if (!arePreferencesEqual(preferences.value, merged)) { | |
| preferences.value = merged | |
| } | |
| } | |
| } | |
| async function initSync(): Promise<void> { | |
| if (syncInitialized || import.meta.server) return | |
| syncInitialized = true | |
| setupRouteGuard(() => preferences.value) | |
| setupBeforeUnload(() => preferences.value) | |
| if (isAuthenticated.value) { | |
| try { | |
| const serverPrefs = await loadFromServer() | |
| if (serverPrefs) { | |
| const merged = { ...preferences.value, ...serverPrefs } | |
| if (!arePreferencesEqual(preferences.value, merged)) { | |
| preferences.value = merged | |
| } | |
| } | |
| } catch (_err) { | |
| // allow init to complete; sync status is handled by useUserPreferencesSync | |
| } | |
| } | |
| } |
| // Read user preferences from localStorage | ||
| const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}') | ||
|
|
||
| const accentColorId = settings.accentColorId | ||
| const accentColorId = preferences.accentColorId |
There was a problem hiding this comment.
Guard JSON.parse to avoid prehydrate crashes on malformed localStorage.
If a user (or browser) stores invalid JSON, the current parse will throw and short‑circuit the prehydrate logic. You already guard other parsing (package manager), so mirroring that here keeps behaviour consistent and resilient.
🛠️ Suggested hardening
// Read user preferences from localStorage
- const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
+ const safeParse = (raw) => {
+ if (!raw) return {}
+ try {
+ const parsed = JSON.parse(raw)
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
+ } catch {
+ return {}
+ }
+ }
+ const preferences = safeParse(localStorage.getItem('npmx-user-preferences'))
@@
- const sidebar = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
- document.documentElement.dataset.collapsed = sidebar.sidebar?.collapsed?.join(' ') ?? ''
+ const sidebar = safeParse(localStorage.getItem('npmx-settings'))
+ document.documentElement.dataset.collapsed =
+ Array.isArray(sidebar.sidebar?.collapsed) ? sidebar.sidebar.collapsed.join(' ') : ''As per coding guidelines, “Use error handling patterns consistently”.
Also applies to: 53-54
e56d0d9 to
21882ac
Compare
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (2)
app/pages/settings.vue (1)
66-100: Remove the non‑essential template comment.
Line 66 is descriptive rather than explaining complex logic; consider dropping it to keep templates lean.As per coding guidelines: “Add comments only to explain complex logic or non-obvious implementations”.♻️ Suggested tidy‑up
- <!-- Sync status indicator for authenticated users -->test/nuxt/components/HeaderConnectorModal.spec.ts (1)
104-106: Reset the wholemockUserLocalSettingsobject to avoid cross‑test leakage.
Onlyconnectoris reset; if future tests mutatesidebar, state will bleed between tests.Proposed fix
function resetMockState() { mockState.value = { connected: false, connecting: false, npmUser: null, avatar: null, operations: [], error: null, lastExecutionTime: null, } - mockUserLocalSettings.value.connector = { - autoOpenURL: false, - } + mockUserLocalSettings.value = { + sidebar: { + collapsed: [], + }, + connector: { + autoOpenURL: false, + }, + } }
| onPrehydrate(el => { | ||
| const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') | ||
| const id = settings.accentColorId | ||
| const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}') | ||
| const id = preferences.accentColorId |
There was a problem hiding this comment.
Guard JSON.parse in onPrehydrate to prevent crashes.
If localStorage.getItem('npmx-user-preferences') contains malformed JSON, the parse will throw and break the prehydrate logic. Wrap in try/catch for resilience.
🛠️ Suggested fix
onPrehydrate(el => {
- const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
- const id = preferences.accentColorId
+ let id = null
+ try {
+ const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
+ id = preferences?.accentColorId
+ } catch {
+ // Ignore malformed JSON
+ }
if (id) {As per coding guidelines, "Use error handling patterns consistently".
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| onPrehydrate(el => { | |
| const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') | |
| const id = settings.accentColorId | |
| const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}') | |
| const id = preferences.accentColorId | |
| onPrehydrate(el => { | |
| let id = null | |
| try { | |
| const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}') | |
| id = preferences?.accentColorId | |
| } catch { | |
| // Ignore malformed JSON | |
| } |
| function setAccentColor(id: AccentColorId | null) { | ||
| if (id) { | ||
| document.documentElement.style.setProperty('--accent-color', `var(--swatch-${id})`) | ||
| } else { | ||
| document.documentElement.style.removeProperty('--accent-color') | ||
| } | ||
| preferences.value.accentColorId = id | ||
| } |
There was a problem hiding this comment.
Guard DOM access for SSR safety.
Similar to useBackgroundTheme, setAccentColor directly accesses document.documentElement.style, which will throw during SSR.
🛠️ Suggested fix
function setAccentColor(id: AccentColorId | null) {
+ if (import.meta.server) {
+ preferences.value.accentColorId = id
+ return
+ }
if (id) {
document.documentElement.style.setProperty('--accent-color', `var(--swatch-${id})`)
} else {
document.documentElement.style.removeProperty('--accent-color')
}
preferences.value.accentColorId = id
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function setAccentColor(id: AccentColorId | null) { | |
| if (id) { | |
| document.documentElement.style.setProperty('--accent-color', `var(--swatch-${id})`) | |
| } else { | |
| document.documentElement.style.removeProperty('--accent-color') | |
| } | |
| preferences.value.accentColorId = id | |
| } | |
| function setAccentColor(id: AccentColorId | null) { | |
| if (import.meta.server) { | |
| preferences.value.accentColorId = id | |
| return | |
| } | |
| if (id) { | |
| document.documentElement.style.setProperty('--accent-color', `var(--swatch-${id})`) | |
| } else { | |
| document.documentElement.style.removeProperty('--accent-color') | |
| } | |
| preferences.value.accentColorId = id | |
| } |
| function setBackgroundTheme(id: BackgroundThemeId | null) { | ||
| if (id) { | ||
| document.documentElement.dataset.bgTheme = id | ||
| } else { | ||
| document.documentElement.removeAttribute('data-bg-theme') | ||
| } | ||
| preferences.value.preferredBackgroundTheme = id | ||
| } |
There was a problem hiding this comment.
Guard DOM access for SSR safety.
setBackgroundTheme directly accesses document.documentElement, which will throw during SSR. Either guard the DOM manipulation or ensure this composable is only ever called client-side.
🛠️ Suggested fix
function setBackgroundTheme(id: BackgroundThemeId | null) {
+ if (import.meta.server) {
+ preferences.value.preferredBackgroundTheme = id
+ return
+ }
if (id) {
document.documentElement.dataset.bgTheme = id
} else {
document.documentElement.removeAttribute('data-bg-theme')
}
preferences.value.preferredBackgroundTheme = id
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function setBackgroundTheme(id: BackgroundThemeId | null) { | |
| if (id) { | |
| document.documentElement.dataset.bgTheme = id | |
| } else { | |
| document.documentElement.removeAttribute('data-bg-theme') | |
| } | |
| preferences.value.preferredBackgroundTheme = id | |
| } | |
| function setBackgroundTheme(id: BackgroundThemeId | null) { | |
| if (import.meta.server) { | |
| preferences.value.preferredBackgroundTheme = id | |
| return | |
| } | |
| if (id) { | |
| document.documentElement.dataset.bgTheme = id | |
| } else { | |
| document.documentElement.removeAttribute('data-bg-theme') | |
| } | |
| preferences.value.preferredBackgroundTheme = id | |
| } |
| const { preferences } = useUserPreferencesState() | ||
| const settingsLocale = preferences.value.selectedLocale | ||
|
|
||
| if ( | ||
| settingsLocale && | ||
| // Check if the value is a supported locale | ||
| locales.value.map(l => l.code).includes(settingsLocale) && | ||
| // Check if the value is not a current locale | ||
| settingsLocale !== locale.value | ||
| ) { | ||
| setLocale(settingsLocale) | ||
| const matchedLocale = locales.value.map(l => l.code).find(code => code === settingsLocale) | ||
|
|
||
| if (matchedLocale && matchedLocale !== locale.value) { | ||
| setLocale(matchedLocale) |
There was a problem hiding this comment.
Guard against unset preferences during early boot.
If preferences.value can be null/undefined before hydration, Line 10 will throw and prevent locale initialisation. A small null-guard keeps this safe.
Proposed fix
- const settingsLocale = preferences.value.selectedLocale
+ const settingsLocale = preferences.value?.selectedLocale📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { preferences } = useUserPreferencesState() | |
| const settingsLocale = preferences.value.selectedLocale | |
| if ( | |
| settingsLocale && | |
| // Check if the value is a supported locale | |
| locales.value.map(l => l.code).includes(settingsLocale) && | |
| // Check if the value is not a current locale | |
| settingsLocale !== locale.value | |
| ) { | |
| setLocale(settingsLocale) | |
| const matchedLocale = locales.value.map(l => l.code).find(code => code === settingsLocale) | |
| if (matchedLocale && matchedLocale !== locale.value) { | |
| setLocale(matchedLocale) | |
| const { preferences } = useUserPreferencesState() | |
| const settingsLocale = preferences.value?.selectedLocale | |
| const matchedLocale = locales.value.map(l => l.code).find(code => code === settingsLocale) | |
| if (matchedLocale && matchedLocale !== locale.value) { | |
| setLocale(matchedLocale) |
| // Get preferences and disable includeTypesInInstall directly | ||
| const { preferences } = useUserPreferencesState() | ||
| preferences.value.includeTypesInInstall = false |
There was a problem hiding this comment.
Avoid leaking preference state across tests.
useUserPreferencesProvider caches a module‑level ref, so mutating includeTypesInInstall here can persist into later tests even if localStorage is cleared. Reset it after the assertion (or in an afterEach) to keep tests isolated.
🧹 Suggested fix
expect(showTypesInInstall.value).toBe(false)
expect(fullInstallCommand.value).toBe('npm install express')
+ preferences.value.includeTypesInInstall = true| it('initializes with default values when storage is empty', () => { | ||
| mountWithSetup(() => { | ||
| const { preferences } = usePackageListPreferences() | ||
| onMounted(() => { | ||
| expect(preferences.value).toEqual(DEFAULT_PREFERENCES) | ||
| }) | ||
| }) | ||
| }) | ||
|
|
||
| it('loads and merges values from localStorage', () => { | ||
| mountWithSetup(() => { | ||
| const stored = { viewMode: 'table' } | ||
| setLocalStorage(stored) | ||
| const { preferences } = usePackageListPreferences() | ||
| onMounted(() => { | ||
| expect(preferences.value.viewMode).toBe('table') | ||
| expect(preferences.value.paginationMode).toBe(DEFAULT_PREFERENCES.paginationMode) | ||
| expect(preferences.value.pageSize).toBe(DEFAULT_PREFERENCES.pageSize) | ||
| expect(preferences.value.columns).toEqual(DEFAULT_PREFERENCES.columns) | ||
| }) | ||
| }) | ||
| }) | ||
|
|
||
| it('handles array merging by replacement', () => { | ||
| mountWithSetup(() => { | ||
| const stored = { columns: [] } | ||
| setLocalStorage(stored) | ||
| const { preferences } = usePackageListPreferences() | ||
| onMounted(() => { | ||
| expect(preferences.value.columns).toEqual([]) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Assertions inside onMounted are not awaited; tests can silently pass.
The test body finishes before onMounted runs, so failures may be missed. Consider awaiting nextTick() and asserting in the test body.
Suggested pattern (apply similarly to other tests)
-import { defineComponent, onMounted } from 'vue'
+import { defineComponent, nextTick } from 'vue'
-function mountWithSetup(run: () => void) {
- return mount(
+async function mountWithSetup<T>(setupFn: () => T) {
+ let result: T
+ const wrapper = mount(
defineComponent({
name: 'TestHarness',
setup() {
- run()
+ result = setupFn()
return () => null
},
}),
{ attachTo: document.body },
)
+ await nextTick()
+ return { wrapper, result: result! }
}
-it('initializes with default values when storage is empty', () => {
- mountWithSetup(() => {
- const { preferences } = usePackageListPreferences()
- onMounted(() => {
- expect(preferences.value).toEqual(DEFAULT_PREFERENCES)
- })
- })
-})
+it('initializes with default values when storage is empty', async () => {
+ const { result, wrapper } = await mountWithSetup(() => usePackageListPreferences())
+ expect(result.preferences.value).toEqual(DEFAULT_PREFERENCES)
+ wrapper.unmount()
+})| onPrehydrate(el => { | ||
| const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') | ||
| const id = settings.preferredBackgroundTheme | ||
| const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}') |
There was a problem hiding this comment.
here too we should try/catch the parse call and probably just fall back to {} like we do if it isn't set
21882ac to
7bd9568
Compare
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
test/nuxt/components/compare/PackageSelector.spec.ts (1)
112-113:⚠️ Potential issue | 🟡 MinorGuard against
undefinedbefore callingtrigger.The
.find()method on an array can returnundefinedif no element matches. Using!without a preceding check could cause a runtime error if the component's markup changes and no button contains the expected icon class.Suggested fix
const removeButton = component.findAll('button').find(b => b.find('.i-lucide\\:x').exists()) + expect(removeButton).toBeDefined() await removeButton!.trigger('click')As per coding guidelines: "Ensure you write strictly type-safe code, for example by ensuring you always check when accessing an array value by index".
| @@ -0,0 +1,4 @@ | |||
| export function useRelativeDates() { | |||
There was a problem hiding this comment.
maybe this should also be called useRelativeDatesPreference?
since nuxt magic will make it global, so the name might end up ambiguous in other contexts
|
|
||
| function arePreferencesEqual(a: UserPreferences, b: UserPreferences): boolean { | ||
| const keys = Object.keys(DEFAULT_USER_PREFERENCES) as (keyof typeof DEFAULT_USER_PREFERENCES)[] | ||
| return keys.every(key => a[key] === b[key]) |
There was a problem hiding this comment.
what if we ever add or remove a key? should we also check the length here?
Note: I came from a React background, quite a newbie to the Nuxt/Vue ecosystem. Please let me know if any patterns are misplaced. Happy to learn and adjust!
Core implementation details:
useUserLocalSettingscomposablepreferences-sync.client.tsplugin for early color mode + server sync init<ClientOnly>to prevent SSR hydration mismatchuseColorModePreferencecomposable to sync color mode with@nuxtjs/color-modeToDo:
searchProvider, which was added while this MR was openconnector, which was added while this MR was openQuestions:
npmx-settingsLS, as those will now live in separate place?client.tssuffix for useUserPreferencesSync.client.ts to ensure it is client-side only. Is this the standard convention to prevent server-side execution?preferences-sync.client.tsplugin for nowI noticed initPreferencesOnPrehydrate, which retrieves some settings from LS on the client, but it doesn't appear to support data fetching. Few other places also using
onPrehydrate.I am curious as we can load preferences during SSR too and can hydrate client with the preferences right away (if logged in). What files/places should I look at, any suggestions?
Closes #484
Needs to be discussed - Done ✅
During the implementation, I identified inconsistent local storage usage across the app:
npmx-color-mode- used for color mode. It is adjustable via the settings page, but is also evaluated bylighthouseand referenced in thenuxt.configcolorModepropertynpmx-list-prefs- used in search to modify the viewing experiencenpmx-settings- contains settings found in/settingsroute as well as unrelated sidebar states on package page (see image below)Based on the feature requirements, I decided to create the
user-preferencesschema specifically for the/settingspage configuration. However, the currentuseSettingshook combines both user-preferences and "sidebar states".I want to align with the team on the execution strategy before finalizing these changes.
Proposed plan:
npmx-color-mode), but connect to user-preference serviceThe solution I am drafting centralizes these options into a single user-preference service. However, if we include items outside of /settings, we need to consider: